-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Framework: Capture and recover from application error #3208
Conversation
b9fe096
to
7fb2741
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Works pretty well.
I'm wondering about metaboxes, what happens when they raise in error. Should we have a special ErrorBoundary for metaboxes? Especially in this PR #3345
} | ||
|
||
render() { | ||
const { className, children } = this.props; | ||
// Disable reason: Exclude from spread props passed to Button |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I usually use omit
for this. I can follow this approach for consistency though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ESLint warning is not great, and we've had to disable it elsewhere. I just found this today, and am inclined to enable the option for our configuration:
https://eslint.org/docs/rules/no-unused-vars#ignorerestsiblings
omit
is fine too, but means an extra dependency and also duplicating the references (once in destructure, once in omit keys listing).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess it wouldn't be a verbatim duplication, since text
here is in-fact unused. I see some value in leaving ignoreRestSIblings
as the default false
behavior, but... it is a convenient omitting technique. Hmm!
editor/error-boundary/index.js
Outdated
<Button onClick={ this.reboot } isLarge> | ||
{ __( 'Attempt Recovery' ) } | ||
</Button> | ||
<ClipboardButton text={ this.getStateText } isLarge> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In addition to the state, is there a way to provide the stack trace. People could send us both to debug quickly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that makes sense. Maybe they can be combined into a single "Copy Debug Info" button?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me
editor/store.js
Outdated
* @return {Redux.Store} Redux store | ||
*/ | ||
function createReduxStore() { | ||
function createReduxStore( initialState ) { | ||
const enhancers = [ | ||
applyMiddleware( multi, refx( effects ) ), | ||
storePersist( 'preferences', GUTENBERG_PREFERENCES_KEY ), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens to the preferences when we use an "initialState" should we ignore hydrating them?
editor/store.js
Outdated
@@ -33,7 +34,7 @@ function createReduxStore() { | |||
enhancers.push( window.__REDUX_DEVTOOLS_EXTENSION__() ); | |||
} | |||
|
|||
const store = createStore( reducer, flowRight( enhancers ) ); | |||
const store = createStore( reducer, initialState, flowRight( enhancers ) ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are updating this line, can we inline this const
? It seems obsolete.
editor/warning/index.js
Outdated
*/ | ||
import './style.scss'; | ||
|
||
function Warning( { children } ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea to move it up and make it a general purpose component 👍
editor/store.js
Outdated
* @return {Redux.Store} Redux store | ||
*/ | ||
function createReduxStore() { | ||
function createReduxStore( initialState ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to rename it to preloadedState
to match with Redux API? See: https://github.com/reactjs/redux/blob/e58e207213161042cb5853ddb6acb0d65034c3b0/src/createStore.js#L39.
editor/index.js
Outdated
> | ||
<ErrorBoundary onError={ reboot }> | ||
<Layout /> | ||
</ErrorBoundary> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When createEditorInstance
is executed on the initial page load we also run initializeMetaBoxes
. I think we don't need to do it again when rebooting, but wanted to double check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
createEditorInstance
is executed on the initial page load we also runinitializeMetaBoxes
. I think we don't need to do it again when rebooting, but wanted to double check.
Correct, I would expect we shouldn't need to re-dispatch the action, since the effects of the original initialization should be reflected in the state that is used for rebooting.
editor/index.js
Outdated
const target = document.getElementById( id ); | ||
|
||
// When an unhandled error occurs, user can choose to reboot the editor, | ||
// replacing a previous mount using initialState from the crashed state. | ||
unmountComponentAtNode( target ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that we don't need to call unmountComponentAtNode
when no initialState
is passed. Not sure if we really need to change that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that we don't need to call
unmountComponentAtNode
when noinitialState
is passed. Not sure if we really need to change that.
Yeah, it doesn't harm anything to keep it this way, but could make things clearer if the condition were to demonstrate it only occurs when rebooting.
editor/index.js
Outdated
* @return {Object} Editor interface. Currently supports metabox initialization. | ||
*/ | ||
export function createEditorInstance( id, post, settings ) { | ||
export function createEditorInstance( id, post, settings, initialState ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a bit confusing for me when I look at it for the first time. At the moment we pass post
which gets later injected into Redux state, settings
which are passed directly to the EditorProvider
and then optional initialState
which again operates on Redux state. My question is if it would make sense to provide a different handler for rebooting. It will increase the number of lines and introduce some code duplication, but it might improve readability here. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR looks like a great improvement and solves a real problem. I think I encountered this issue a few times already. I left some comments, but they aren't blockers. I was trying to better understand how Redux store works in Gutenberg, so I left some questions where I wasn't sure how things work. I also added some notes where I think we might consider refactoring the existing code.
I think that's reasonable. This is definitely meant as a last-resort measure, and we should be more granular (and ideally more user-friendly) with error boundaries further down in the tree (block error boundaries being a good example). |
I was thinking about something along those lines to make diff --git a/editor/index.js b/editor/index.js
index 937e7b35..8836b954 100644
--- a/editor/index.js
+++ b/editor/index.js
@@ -45,6 +45,31 @@ window.jQuery( document ).on( 'heartbeat-tick', ( event, response ) => {
} );
/**
+ * When an unhandled error occurs, user can choose to reboot the editor,
+ * replacing a previous mount using initialState from the crashed state.
+ *
+ * @param {Element} target Target element where editor gets recreated
+ * @param {?Object} settings Editor settings object
+ * @param {?Object} initialState Initial editor state to hydrate
+ */
+function recreateEditorInstance( target, settings, initialState ) {
+ unmountComponentAtNode( target );
+ const reboot = recreateEditorInstance.bind( null, target, settings );
+
+ render(
+ <EditorProvider
+ settings={ settings }
+ initialState={ initialState }
+ >
+ <ErrorBoundary onError={ reboot }>
+ <Layout />
+ </ErrorBoundary>
+ </EditorProvider>,
+ target
+ );
+}
+
+/**
* Initializes and returns an instance of Editor.
*
* The return value of this function is not necessary if we change where we
@@ -53,22 +78,16 @@ window.jQuery( document ).on( 'heartbeat-tick', ( event, response ) => {
* @param {String} id Unique identifier for editor instance
* @param {Object} post API entity for post to edit
* @param {?Object} settings Editor settings object
- * @param {?Object} initialState Initial editor state to hydrate
* @return {Object} Editor interface. Currently supports metabox initialization.
*/
-export function createEditorInstance( id, post, settings, initialState ) {
+export function createEditorInstance( id, post, settings ) {
const target = document.getElementById( id );
-
- // When an unhandled error occurs, user can choose to reboot the editor,
- // replacing a previous mount using initialState from the crashed state.
- unmountComponentAtNode( target );
- const reboot = createEditorInstance.bind( null, id, post, settings );
+ const reboot = recreateEditorInstance.bind( null, target, settings );
const provider = render(
<EditorProvider
settings={ settings }
post={ post }
- initialState={ initialState }
>
<ErrorBoundary onError={ reboot }>
<Layout />
diff --git a/editor/provider/index.js b/editor/provider/index.js
index 75262812..e85dfec5 100644
--- a/editor/provider/index.js
+++ b/editor/provider/index.js
@@ -45,9 +45,7 @@ class EditorProvider extends Component {
const store = createReduxStore( props.initialState );
- // If initial state is passed, assume that we don't need to initialize,
- // as in the case of an error recovery.
- if ( ! props.initialState ) {
+ if ( props.post ) {
store.dispatch( setupEditor( props.post ) );
} |
This seems like a really good idea. We've hit quite a few cases recently where the screen has just gone blank and we've suffered possible content loss. |
@EphoxJames Might be a good idea to provide a way to copy the HTML content when it happens. |
@youknowriad I will pass that on to @ephox-mogran 😃 |
7fb2741
to
cc2da7d
Compare
Codecov Report
@@ Coverage Diff @@
## master #3208 +/- ##
==========================================
- Coverage 34.22% 34.16% -0.07%
==========================================
Files 257 258 +1
Lines 6740 6755 +15
Branches 1223 1224 +1
==========================================
+ Hits 2307 2308 +1
- Misses 3741 3754 +13
- Partials 692 693 +1
Continue to review full report at Codecov.
|
Closes #3397 Consistency with new application-wide error capturing messaging.
cc2da7d
to
cc658ae
Compare
const reboot = recreateEditorInstance.bind( null, target ); | ||
|
||
render( | ||
<EditorProvider initialState={ initialState }> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you confirm that we can safely omit „settings” prop here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, on second glance, I think this may be needed. I had assumed this was being used to initialize state and therefore would be preserved in the reboot, but in fact this is part of a separate context. Will create a pull request to reintroduce.
Related: #2410
This pull request seeks to add application-wide error handling to try to prevent content loss in the case that an unhandled error occurs in editor rendering. This is intended as a last-resort recovery to avoid a "blank screen" case, and favors state integrity over presentation, in order to avoid any risk that an error occurs during the recovery itself. There could be improvements made here to try to alleviate any stress to a user who finds themselves in this scenario.
As implemented, the user is provided options to attempt a recovery or copy the raw state data to their clipboard. In the former case, the behavior is to attempt a re-render, providing as initial state to the Redux store the state which had existed at the time of the crash. It is possible that this recovery may not be possible, if it is the state itself which is the cause of the error. Therefore, in order to provide some means of recovery, the user is also presented the option to retrieve the underlying state data from which they may be able to extract remnants of their post state.
Video Demonstration
Testing instructions:
Ideally there is no way to trigger an application error. You may introduce one though, ideally which doesn't occur at initial render. I have been using the following to trigger an error when a dropdown is opened (e.g. inserter menu):
When an application error occurs, note that you are presented with a dialog with options to either attempt recovery or copy to state. Attempt recovery should restore the editor to its state prior to the error occurring. Copying state should copy a (very large) serialized state object to your clipboard.